Integrating a Static Blog in a Next.js Project with Velite
In this article, I'll explain how to use Velite to add a static blog feature to a website built with Next.js. The final result will be similar to the blog system on this site.
Why I Built Another Blog
Yes, I've integrated another blog system into my personal homepage. Before this, I already had a personal blog called "Subjective World", where I occasionally share my thoughts. However, I've always positioned that blog to share only my personal insights or book notes, more life-oriented content. From the beginning, I never intended to publish anything related to technical tinkering there, and I even decided not to include any images, keeping it purely text-based.
But sometimes I still have the urge to share some tinkering records. I tried using GitHub Discussions to publish, which is honestly sufficient and has all the necessary features. However, the tinkering gene always gets restless, and I can't help but find flaws in this approach, such as the lack of customization, the deep entry point making it hard to discover, and the inability to drive traffic to my own website, among others.
Naturally, this blog should be integrated into my personal homepage and should have a high degree of customization capability. And since I'm moving away from GitHub, I must add some features that weren't there before, so I implemented multilingual translation. Now, if you go back to the title of this article, you'll see language switch links for English and Chinese below.
Why Use a Static Blog
Nowadays, there are generally two choices for setting up a blog: dynamic blogs represented by Wordpress, Ghost, Typeecho, etc., and static blogs represented by Hugo, Hexo, Jekyll, etc. A clear distinction between static and dynamic blogs is whether they use a database. If there's a database, it's a dynamic blog; if not, it's a static blog.
The advantages of dynamic blogs are strong extensibility, interactivity, user-friendliness for non-technical people, and powerful, rich features. The disadvantages are difficulty in migration, high usage costs, and if you want to implement it yourself rather than using existing solutions, the effort required is hard to estimate. If you want to deploy it yourself, you need to use complete servers or container services, which are expensive infrastructure.
The advantages of static blogs are purity (because they're all in file form) and low cost, but they're not very friendly to non-technical people. Also, because there's no database, you need to use some tricks to implement interactive features like comments and likes. Platforms like GitHub Pages, Cloudflare Pages, Vercel, etc., all provide free static website hosting services, and static websites are identical no matter where they're deployed, so they can be migrated at any time. Therefore, static blogs are almost zero-cost except for time.
My personal homepage is already hosted on Cloudflare Pages, and I don't want to introduce a behemoth like a database for now. Plus, I'm not too concerned about interactivity. So I chose a static blog without hesitation.
Why Choose Velite
Velite is an open-source JavaScript content conversion tool that can convert Markdown/MDX, YAML, and other files into type-safe JavaScript data. For example, to implement a static blog, you can use it to convert Markdown format files into data, and then display it in your code. It might seem a bit roundabout, why not use ready-made solutions like Hugo or Hexo, which directly generate static websites from Markdown?
But there are subtle differences between these two models. If you use tools like Hugo or Hexo, they help you generate the entire website, rather than making it part of an existing website. There are some methods to implement it as part of an existing website, but they all tend to feel unnatural and inelegant.
I want to make the blog a part of my personal homepage, not the entire body. Although this homepage currently has no dynamic content, to have the ability to expand in the future, I used Next.js to develop this website from the start. In this case, using Velite feels very natural, converting blog content into data, then using it in Next.js to display. Moreover, combined with MDX, you can even embed some React components in articles, possibly creating some eye-catching gimmicks.
Velite is probably not the first tool of its kind. There's a much more famous one called Contentlayer, which I used for my "Subjective World" blog. I actually found Velite while searching for alternatives to Contentlayer.
Although Contentlayer is famous, it has many problems:
- It doesn't handle static assets well
- Maintenance is inactive, with no updates for over half a year
- The documentation is too brief, and often encounters
500
server errors
As of March 2024, Contentlayer's last update was seven months ago, and looking through GitHub PRs, you can see that the vast majority of PRs are closed without being merged. This indicates that the original author no longer has the intention to update. Even the documentation often can't be opened, making it impossible to use.
Moreover, in my imagination, the file structure of a blog should be one folder per article, with the article's Markdown file, translations in different languages, images, and even videos - all resources related to the article placed in the same directory for easy organization. But Contentlayer doesn't seem to be able to use this directory structure because it doesn't process static files. Files like images need to be manually placed in the public
directory, hosted by Next.js, and then directly referenced in the Markdown file. This means resources for the same article have to be placed in different directories, making management very troublesome. Velite, on the other hand, supports directly copying static files referenced in Markdown files to public
during build time, which is much more convenient. Moreover, in Velite's official examples, they use the same directory structure as I imagined, perhaps the author made this tool out of similar needs.
Velite uses Zod to provide data type checking, which can greatly ensure type safety and reduce maintenance burden. It also provides features like slug
duplication detection and directory generation.
So based on these reasons, I ultimately chose to use Velite to integrate blog functionality into my personal homepage.
Getting Started with Velite
Velite has detailed usage documentation on its official website. If you want to carefully study what it can be used for and some specific details, you can go directly to the official website to check. Here, I'll just briefly introduce the process of integrating a static blog in a Next.js project using Velite.
I performed the subsequent operations in a project created using
npx create-next-app@latest
, which uses all default settings, i.e., TypeScript, TailwindCSS, and App Router are enabled, etc. For specific details, refer to the Next.js official documentation. If your project structure is different from mine, you can make some relevant changes yourself.
The workflow of Velite is:
- Read the contents of the
velite.config.js
orvelite.config.ts
configuration file - According to the definitions in the configuration file, read and process the contents of the directory specified by
root
in the configuration file - Output the processed results to the directory set by
output
in the configuration file - In the Next.js project, directly import the results processed by Velite, and then you can start various operations
Installing Velite
First, we need to install Velite before we can properly import Velite-related configurations. Open the terminal and execute the following command:
# If you are using a different package manager, use the corresponding command
npm install velite
Adding Necessary Configuration
As mentioned above, we will use the velite.config.ts
configuration file to tell Velite how to work. So let's create a file named velite.config.ts
in the project root directory and fill it in with the following content:
// velite.config.ts
import { defineConfig } from "velite";
export default defineConfig({
root: "content",
output: {
data: ".velite",
assets: "public/static",
base: "/static/",
name: "[name]-[hash:6].[ext]",
clean: true,
},
});
This configuration tells Velite:
- Process files in the
content
directory - Place the processed results in the
.velite
directory - Put referenced static assets like images and videos into the
/public/static
directory - The reference address for processed static assets is
/static/**
(because Next.js hosts files in thepublic
directory at the root of the website, so the/public/static/**
directory corresponds to the address/static/**
) - The filename for processed static assets is
[name]-[hash:6].[ext]
- Velite cleans the output directory before building
From this, we can see that the files in the .velite
and public/static
directories are automatically generated by Velite, so we shouldn't let Git track their changes. Therefore, we can add these two directories to .gitignore
:
# .gitignore
# Existing content
# Velite
.velite
public/static
After adding these two lines to .gitignore
, Velite will not affect the state of our code repository when working.
If we execute the npx velite
command, Velite will start processing files according to our definitions (because we haven't told it how to process which files, so it won't process anything). However, every time we want to execute npx velite
in the development process, it's a bit cumbersome. So we can integrate the processing into Next.js, and let Velite automatically detect file changes and process them during next dev
or next build
.
To achieve this operation, we can add Velite's processing logic in next.config.js
. However, because this configuration uses await
at the root of the file, we need to convert the file to ES Module format. Therefore, rename next.config.js
to next.config.mjs
and replace the content with the following configuration:
// next.config.mjs
const isDev = process.argv.indexOf('dev') !== -1
const isBuild = process.argv.indexOf('build') !== -1
if (!process.env.VELITE_STARTED && (isDev || isBuild)) {
process.env.VELITE_STARTED = '1'
const { build } = await import('velite')
await build({ watch: isDev, clean: !isDev })
}
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
swcMinify: true,
};
export default nextConfig;
If you have modified the default configuration file of Next.js before, you can add the Velite-related logic according to your configuration.
After adding the above configuration, we can use Velite as usual without worrying about it, and when we want to use the processed files, we can directly import the contents of .velite
.
If our project structure is deep, to avoid the situation of import {posts} from '../../../../velite'
, we can add a convenient path in the TypeScript configuration file. Open the tsconfig.json
file in the project root directory, and add the following data:
{
"compilerOptions": {
... Other config
"paths": {
"@/*": ["./*"]
}
}
... Other config
}
After adding the above configuration, I can easily use import {posts} from '@/velite'
to import the processed files.
Define file processing methods
So far, we have completed all the necessary configurations for Velite, but we still need to tell Velite which files to process and how to process them.
Let's go back to the Velite configuration file velite.config.ts
, where we have only added the root
and output
fields, but haven't told Velite our blog content format and directory structure. Because we are building a blog system, we need to define the structure of posts (Post) and categories (Category). So we can define these types in the configuration file, open the velite.config.ts
file, and add the following content:
// velite.config.ts
import { defineCollection, defineConfig, s } from "velite";
const categories = defineCollection({
name: "Category",
pattern: "categories/*.yml",
schema: s.object({
slug: s.slug("posts", ["admin", "login"]),
name: s.string(),
description: s.string(),
}),
});
const posts = defineCollection({
name: "Post",
pattern: "posts/**/*.md",
schema: s.object({
title: s.string().max(99),
slug: s.slug(),
date: s.isodate(),
description: s.string().max(999).optional(),
categories: s.array(s.string()),
toc: s.toc(),
content: s.markdown(),
}),
});
export default defineConfig({
root: "content",
output: {
data: ".velite",
assets: "public/static",
base: "/static/",
name: "[name]-[hash:6].[ext]",
clean: true,
},
collections: { categories, posts },
});
The above configuration tells Velite that we have Category
and Post
data.
- The
*.yml
files in thecontent/categories/
directory will be parsed into Category data. Each file should contain the fieldsslug
,name
, anddescription
, all of which are string types. The difference is thatslug
will check for uniqueness and cannot beadmin
orlogin
. - The
*.md
files in thecontent/posts/
directory will be parsed into Post data. The header of the Markdown file needs to contain the fields:title
,slug
,date
,description
, andcategories
, their types can be inferred from the type definitions and meanings. Thetoc
andcontent
fields will be automatically filled in by Velite based on the content of the Markdown file.
Now, we can start adding articles and categories. Suppose we have a development
category, we can create a content/categories/development.yml
file and add the following content:
slug: development
name: Development
description: Sharing my experiences in programming
Then, we can add our first article by creating a file content/posts/hello-world.md
and filling in the following content:
---
title: Hello, world!
slug: hello-world
date: 2024-03-17 00:00:00
categories:
- development
---
Hello! This is my first article on my blog.
Now we can directly import our category information and article content from the .velite
directory.
Using Velite's generated data in Next.js
After the above settings, we can directly import the data built by Velite in the .velite
directory, and then use it in the page as needed.
- Read category information
import { categories } from "@/.velite";
export default function Page() {
return (
<ul>
{categories.map((category) => (
<li key={category.slug}>
<a href={`/categories/${category.slug}`}>{category.name}</a>
</li>
))}
</ul>
);
}
- Read article list
import { posts } from "@/.velite";
export default function Page() {
return (
<ul>
{posts.map((post) => (
<li key={post.slug}>
<a href={`/posts/${post.slug}`}>{post.title}</a>
</li>
))}
</ul>
);
}
- Read article list in a category
import { categories, posts } from "@/.velite";
function getPostsByCategorySlug(categorySlug: string) {
return posts.filter((post) => post.categories.includes(categorySlug));
}
export default function Page() {
const targetCategory = categories[0];
const filteredPosts = getPostsByCategorySlug(targetCategory.slug);
return (
<ul>
{filteredPosts.map((post) => (
<li key={post.slug}>
<a href={`/posts/${post.slug}`}>{post.title}</a>
</li>
))}
</ul>
);
}
Then we can use these data to display, link, optimize styles, etc. in the interface.
Velite can also implement many other functions, such as counting the number of articles in each category, adding code highlighting to Markdown, and implementing internationalization.
More usage methods can be found in my blog source code, or if there are more people who want to see it, I will write an article later to introduce advanced usage.